All files / src/app/api/shared-results/[token]/photos/[photoId] route.ts

81.81% Statements 18/22
81.25% Branches 13/16
100% Functions 3/3
80.95% Lines 17/21

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76                    2x         3x                                 4x 4x 4x   4x 4x 2x     2x 2x       2x 2x       2x 2x 2x 1x     1x                                    
import { NextResponse, type NextRequest } from "next/server";
import { getDb } from "@/lib/db";
import { ShareController } from "@/server/api/shares";
import { ResultController } from "@/server/api/results";
import { ResultItemController } from "@/server/api/result-items";
import { PhotoController } from "@/server/api/photos";
import { servePhotoFile } from "@/lib/photo-serving";
 
// sharp (used by servePhotoFile) is a native addon — pin the Node runtime,
// same as /api/photo-files/[photoId].
export const runtime = "nodejs";
 
// A fresh response per call — NextResponse bodies are single-use, and this
// route is invoked once per request across the process's lifetime.
function notAvailable(): NextResponse {
  return NextResponse.json({ error: "That shared photo isn't available." }, { status: 404 });
}
 
/**
 * Public, token-scoped photo bytes for a shared (finalized, unrevoked) result.
 * Mirrors the shared-results page's own gating (share active + unexpired,
 * result finalized) PLUS a membership check the page doesn't need: the photo's
 * item must be one of the result's ranked result-items, so a share token can
 * never be used to fetch an unrelated photo from the same collection (spec
 * purses-21x5.1). Every gating failure collapses to the same 404 — the
 * existing `GET /api/shared-results/[token]` posture — so a stranger probing
 * the route learns nothing about which part failed.
 */
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ token: string; photoId: string }> },
) {
  try {
    const { token, photoId } = await params;
    const db = getDb();
 
    const share = await ShareController.fromDb(db).findByToken(token);
    if (!share || share.status !== "active" || (share.expiresAt && share.expiresAt < new Date())) {
      return notAvailable();
    }
 
    const result = await ResultController.fromDb(db).getResultOrNull(share.sortResultId);
    Iif (!result || result.status !== "finalized") {
      return notAvailable();
    }
 
    const photo = await PhotoController.fromDb(db).findById(photoId);
    Iif (!photo || !photo.itemId) {
      return notAvailable();
    }
 
    const resultItems = await ResultItemController.fromDb(db).listResultItems(result.id);
    const isRanked = resultItems.some((ri) => ri.itemId === photo.itemId);
    if (!isRanked) {
      return notAvailable();
    }
 
    return servePhotoFile(
      {
        blobKey: photo.blobKey,
        mimeType: photo.mimeType,
        photoId: photo.id,
        collectionId: result.collectionId,
        updatedAt: photo.updatedAt,
        rotation: photo.rotation,
      },
      request,
    );
  } catch (error) {
    // Public route: never echo the underlying error to an unauthenticated
    // caller (same posture as GET /api/shared-results/[token]).
    console.error("shared-results photo fetch failed", error);
    return notAvailable();
  }
}